fix(restore): do not clobber a backup slot the undo skipped - #13
Open
codeAnqiang-ma wants to merge 1 commit into
Open
fix(restore): do not clobber a backup slot the undo skipped#13codeAnqiang-ma wants to merge 1 commit into
codeAnqiang-ma wants to merge 1 commit into
Conversation
The undo direction never overwrites: every conflict check skips the entry and leaves the backup where it is. Redo had no matching check on the store side, so it renamed the live file into a slot that was still holding the only copy of a deleted file, counted it as restored, and that copy was gone. Guard the two redo paths that write into the session store, so an entry whose undo was skipped is skipped on the way back too. This keeps the promise in the package doc that both directions preserve data. Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #12
The bug
The redo direction writes the live file into the backup slot without checking whether that slot is already occupied:
moveAnyleads withos.Rename, which replaces the destination atomically, and its cross-device fallback opens the destination withO_TRUNC. Either way whatever was parked in that slot is gone.The slot is occupied whenever the undo of that entry was skipped, which is an ordinary outcome rather than an error: the undo direction never overwrites, it skips and says why (
path exists again, use --force to overwrite,original path occupied, use --force to overwrite,moved file is gone), and--onlycherry-picks leave untouched entries in the same state. After such a partial undo the backup still holds the only copy of the deleted file. Redoing then renames the live file on top of it and counts the entry as restored.Observed on
mainbefore this change, drivingrestore.Rundirectly:Two files are unrecoverable — the deleted file whose only copy was in the slot, and the file that had been standing at the path — and nothing was reported as skipped.
That contradicts what the package promises in its own doc comment:
OpRename's redo branch has the same shape:moveAny(new_, bak)overwrites abakthat a skipped undo left occupied.The fix
A guard on each of the two redo paths that write into the session store, mirroring the checks the undo direction already performs on the working tree:
OpUnlinkredo, beforemoveAny(field(0), field(1))OpRenameredo, beforemoveAny(new_, bak)Both skip with an explanation instead of overwriting.
Neither guard is gated on
--force, deliberately. On the undo side--forceclobbers a file the user can see and has consciously decided to give up. Here the casualty is a backup inside the session store that the user has no way to inspect beforehand, and losing it makes that entry permanently unrecoverable. Skipping is also the semantically right answer and not merely the cautious one: an entry whose undo never happened is, as far as the working tree is concerned, already in its post-command state, so there is nothing for redo to re-apply.Legitimate redo is unaffected. A successful undo moves the backup out of the slot, so the guard does not fire — e2e case 9 (
rm→ undo →undo redo→ undo) covers exactly that path.Tests
Two regression tests, placed beside the existing
TestUndoUnlinkConflictSkipsWithoutForcewhose shape they follow, one per guarded path. Each drives a genuinely skipped undo first, then redoes, then asserts the backup is still byte-for-byte what it was.Before the fix:
After:
gofmt -l .andgo vet ./...printed nothing.I also checked by hand that the ordinary round trips still complete after the guards —
unlinkundo → redo ends with the file deleted and the backup holding the original,renameundo → redo ends with the file back at its new path andbakholding b's clobbered original — using throwaway tests that are not part of this diff.Not run locally:
make test, i.e.test/e2e.shandtest/hook.sh. I am on macOS, whereshim/undo_shim.cdoes not compile (O_LARGEFILEand other Linux/glibc-isms), somakecannot producelibundo.soand the shim-driven suites cannot start. This change is confined to the Go replay side and touches no shim code or shell hook; CI covers those suites on Linux.Found and fixed with AI assistance. I reproduced the data loss locally, verified every cited line, and reviewed all conclusions before opening this.